I used to think the <button>
element could only exist inside a <form>
and
that it did not support any attributes. In other words, I basically thought it
was a more semantic version of <input type="submit">
.
However, after working on a payment form for a client project, I realized that
the <button>
element is a robust, versatile and under utilized tool. Plus,
knowing how to leverage forms and buttons will benefit you when using Turbo
and Stimulus.
Our base
Here’s a traditional Rails form that we’ll be working with in this tutorial.
It’s nothing special, and the only thing we need to focus on for now is the fact
that the form.button
is located within the form per usual. That’s about to
change.
<%= form_with model: payment,
class: "contents" do |form| %>
<div>
<%= form.label :amount %>
<%= form.number_field :amount, required: true %>
</div>
<div>
<%= form.label :payment_method %>
<%= form.collection_radio_buttons :payment_method_id,
PaymentMethod.all,
:id,
:display_name do |builder| %>
<div>
<%= builder.label do %>
<%= builder.radio_button required: true %>
<%= builder.text %>
<% end %>
</div>
<% end %>
</div>
<div>
<%= form.button "Pay Now" %>
</div>
<% end %>
A simple example
Imagine our designer wants the “Pay Now” button to be fixed to the bottom of the screen in an effort to make it stand out. In order to do this, we’ll need to actually place the button outside of the form.
You might be thinking the only way we can still submit the form with the same button is to use JavaScript, but you’d be wrong. The solution is quite simple.
All we need to do is leverage the form attribute.
The value of this attribute must be the id of a
<form>
in the same document. (If this attribute is not set, the<button>
is associated with its ancestor<form>
element, if any.)
--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -1,4 +1,5 @@
<%= form_with model: payment,
+ id: dom_id(payment),
class: "contents" do |form| %>
-
- <div>
- <%= form.button "Pay Now" %>
- </div>
<% end %>
--- a/app/views/payments/new.html.erb
+++ b/app/views/payments/new.html.erb
@@ -5,3 +5,8 @@
<%= link_to "Back to payments", payments_path %>
</div>
+
+<%= button_tag "Pay Now", form: dom_id(@payment) %>
In order to ensure we map to the correct id
, we utilize the dom_id method
on both the form and button. However, we could have just hard-coded an id
if
we wanted to.
Additionally, we can confirm that hitting the Return key when an element in the form is focused will still submit the form, even though it no longer contains a submit button.
A complex example
Now imagine our designer comes back with a request that would allow a user to delete payment methods on the same form.
Here are a few ways we could achieve this:
- Use the link_to method with
data: { turbo_method: :delete }
. - Add hidden fields to the form and set their values to the IDs of the payment methods that should be deleted.
Although these are both acceptable solutions, there’s a more semantic alternative. Instead, we can easily accomplish this task by leveraging more button attributes.
--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -26,6 +26,12 @@
<%= builder.radio_button required: true %>
<%= builder.text %>
<% end %>
+ <%= form.button "Delete #{builder.object.display_name}",
+ formaction: payment_method_path(builder.object),
+ name: "_method",
+ value: "delete",
+ formnovalidate: true,
+ data: {turbo_confirm: "Delete #{builder.object.display_name}?"} %>
</div>
<% end %>
</div>
So, what’s going on here? The formaction attribute simply overrides the
parent form’s action
. There’s no need to set a formmethod attribute because
the parent form’s method
is already set to "post"
.
The name and value attributes are passed as form data to the controller. We do this to ensure we can make a DELETE request. Here’s what the request looks like:
{
"authenticity_token"=>"123abc",
"payment"=>{"amount"=>"100", "payment_method_id"=>"1"},
"_method"=>"delete", #<- "name" => "value"
"action"=>"destroy",
"id"=>"1"
"controller"=>"payment_methods",
}
You can almost think of this button as a form similar to the following:
<form action="/payment_methods/1" method="post">
<input type="hidden" name="_method" value="delete">
<button>Delete Payment Method</button>
</form>
However, we can further simplify this implementation by leveraging the
formmethod attribute. Although this attribute normally only supports post
,
get
, and dialog
, the form.button
is able to support delete
just like
Rails’ forms.
--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -28,8 +28,7 @@
<% end %>
<%= form.button "Delete #{builder.object.display_name}",
formaction: payment_method_path(builder.object),
- name: "_method",
- value: "delete",
+ formmethod: :delete,
formnovalidate: true,
data: { turbo_confirm: "Delete #{builder.object.display_name}?" },
You might be wondering why we set the formnovalidate attribute. We can demonstrate what it does by temporarily having it removed. You won’t be able to delete a payment method if the rest of the form is invalid.
There is one subtle issue with the current state of our form, but it’s a big one: Hitting the Return key does not submit the form. Instead, it triggers one of the delete payment method buttons.
This is because hitting the Return key will use the first submit button in the form. In this case, that happens to be a button to delete a payment method. To fix this, we can simply add back the submit button, this time at the top of the form. Since we don’t want two “Pay Now” buttons, we can simply make it hidden.
--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -13,6 +13,8 @@
+ <%= form.button "Pay Now", hidden: true %>
+
<div>
<%= form.label :amount %>
<%= form.number_field :amount, required: true %>
In other words, we are making this button inaccessible by design by removing its semantics from the document.